Skip to content

fix(language): make the parser setting real, and Chinese use jieba (#278) - #281

Merged
HugoFara merged 2 commits into
developfrom
fix/278-wire-parser-registry
Aug 27, 2026
Merged

fix(language): make the parser setting real, and Chinese use jieba (#278)#281
HugoFara merged 2 commits into
developfrom
fix/278-wire-parser-registry

Conversation

@HugoFara

Copy link
Copy Markdown
Owner

Fixes #278.

The symptom, reproduced

A Chinese language created from the built-in preset parses a Chinese text to:

sentences=1  words=0

No clickable words at all — nothing to look up, nothing to track. That is the screenshot in the issue.

Three gaps stacked up to it

1. The parser setting was decorative. LgParserType was written by the language form and read by nothing. TextParsing::tokenize() branches only on the legacy MECAB magic word and otherwise goes straight to StandardTextParser. Measured — the value makes no difference at all, including a value that isn't a parser:

LgParserType result
NULL sentences=2 words=13
jieba sentences=2 words=13
regex sentences=2 words=13
mecab sentences=2 words=13
totally-bogus sentences=2 words=13

ParserRegistry had exactly two callers, both just filling the dropdown. CharacterParser, MecabParser and ExternalParser were unreachable from the parsing path.

2. External parsers never reached the dropdown either. ParserRegistry::__construct() takes an optional loader; both direct call sites passed none, so registerExternalParsers() returned immediately. jieba is installed in the published Docker image (Dockerfile:59-65 builds the venv and copies parsers/, matching config/parsers.php) and works — it was simply never listed.

3. The presets could not express a parser. langdefs.json has carried parserType for the CJK languages all along, but LanguagePresets::loadFromJson() flattened it into an eight-slot tuple that dropped the field. That is also why the Japanese preset, declaring mecab, silently resolved to character parsing even with MeCab installed.

What changed

  • TextParsing consults the registry before the built-in pipeline and adapts TokenParsedToken (the shapes differ only in sentence numbering and token ordering). The check-text preview uses the same parser as the save.
  • ParserRegistry builds its own ExternalParserLoader when given none, so jieba and MeCab Python appear on an install that has them.
  • LanguagePresets carries parserType as slot 8; the API exposes it; both preset appliers (the Alpine store and the wizard's DOM applier) set it.
  • Chinese (Simplified) and (Traditional) ask for jieba, keeping makeCharacterWord so the fallback stays meaningful.
  • The form gained an Automatic option, so "infer from the flags" stays expressible. New locale key across all nine languages.

Opting in is deliberate; the fallback is safe

Only an explicit, non-default LgParserType routes to the registry. The legacy signals resolveParserTypeFromRow() also understands — the MECAB magic word and LgSplitEachChar — are deliberately ignored there, so every language that exists today parses byte-identically; none of them stores a parser type.

A parser the server cannot run falls back to the built-in pipeline, never to the regex parser — which for a CJK language is precisely the zero-word text this issue is about.

Verified end to end, real database, jieba installed

preset (jieba)        LgParserType='jieba'     sentences=2 words=7
no parser type        LgParserType=NULL        sentences=2 words=13
unavailable parser    LgParserType='sudachi…'  sentences=2 words=13

7 is jieba word segmentation (我 | 喜欢 | 学习 | 中文 | 这是 | 一个 | 句子); 13 is one token per character.

Checks

Psalm 0 errors · PHPCS 0 errors, 0 warnings · PHPUnit 9101 pass (11 new, covering opt-in resolution, the legacy signals staying inert, the unavailable-parser fallback, and the preset slot) · Vitest 4326 pass · tsc and ESLint clean · assets rebuilt.

Not in this PR

  • The "0 words" warning. Nothing tells you a language's rules match nothing in your text. Worth doing separately as the backstop for every other misconfiguration.
  • The dead NLP /parse/ route. services/nlp/ has a second, complete jieba — JiebaParser, a live endpoint, and a PHP client in NlpServiceHandler::parse() / getAvailableParsers(). Both have zero callers. Two implementations, one a decoy; worth either wiring up or deleting.

@HugoFara HugoFara added enhancement Develop an existing feature ui Any problem related to the User Interface new-feature A new feature labels Aug 23, 2026
@HugoFara HugoFara linked an issue Aug 23, 2026 that may be closed by this pull request
)

A Chinese language created from the built-in preset produced a text with no
clickable words at all: sentences=1, words=0. Nothing to look up, nothing to
track. Three separate gaps stacked up to that.

The parser setting was decorative. LgParserType was written by the language
form and read by nothing — TextParsing::tokenize() branched only on the legacy
MECAB magic word and otherwise went straight to StandardTextParser, so setting
a language to jieba, mecab or even a bogus value parsed identically. The whole
ParserRegistry existed only to populate the dropdown. It is now consulted
before the built-in pipeline, and its tokens are adapted to ParsedToken; the
two shapes differ only in sentence numbering and token ordering.

External parsers never reached the dropdown either. ParserRegistry's
constructor took an optional loader, and both direct call sites passed none,
so registerExternalParsers() returned immediately: jieba was installed in the
Docker image, working, and unlistable. It now builds a loader when given none.

The presets could not express a parser. langdefs.json has carried parserType
for the CJK languages all along, but LanguagePresets flattened it into an
eight-slot tuple that dropped the field — which is why the Japanese preset,
declaring mecab, silently resolved to character parsing. The tuple carries it
as slot 8, the API exposes it, and both preset appliers set it.

Chinese (Simplified) and (Traditional) now ask for jieba, keeping
makeCharacterWord so the fallback stays meaningful.

Opting in is deliberate and the fallback is safe. Only an explicit,
non-default LgParserType routes to the registry; the legacy signals it also
understands are ignored, so every language that exists today — all of which
store no parser type — parses byte-identically. A parser the server cannot run
falls back to the built-in pipeline, never to the regex parser, which for a
CJK language yields zero words. The form gained an "Automatic" option so that
"infer from the flags" stays expressible.

Verified end to end against a real database with jieba installed:

  preset (jieba)        LgParserType='jieba'    sentences=2 words=7
  no parser type        LgParserType=NULL       sentences=2 words=13
  unavailable parser    LgParserType='sudachi'  sentences=2 words=13

where 7 is jieba word segmentation and 13 is one token per character.
The opt-in test asked whether LgParserType was set, on the reasoning that
no language stores one. They do. 20251223_120000_add_parser_type.sql
backfills the column from the very legacy signals the opt-in test excludes:
'mecab' wherever the magic word sits in LgRegexpWordCharacters, 'character'
wherever LgSplitEachChar is set. Every upgraded install with a CJK language
therefore carries a parser type nobody chose, and routing it to the registry
retokenizes the language.

Measured with TextParsing::checkText() on a real 3.4.2 database, same texts,
same settings, before and after:

  lg=2 Chinese  (character, split=1)  12 sent / 103 words -> 13 / 122
  lg=5 Japanese (character, split=1)  13 sent /  46 words -> 14 /  60
  lg=1 French, lg=3 German, lg=4, 6, 7, 8 (no type)       unchanged

Existing texts keep their stored parse, so nothing breaks at upgrade; it
diverges later and quietly, when UpdateLanguage re-parses on a settings
change or a new text is imported. The language then holds texts split two
different ways, and terms link by string, so saved vocabulary stops matching
new occurrences.

Read a type that only restates the flag beside it as the flag, not as a
choice. 'character' with LgSplitEachChar, and 'mecab' with the magic word,
carry no information the built-in pipeline is not already acting on.
Anything else could only have been picked in the form: jieba, an external
tokenizer, or 'character' on a language whose split flag is off, since the
backfill never wrote that combination.

Re-measured after the change: every language matches the pre-#281 baseline
exactly. A language set to jieba still routes to jieba, and one naming a
parser the server cannot run still falls back to the built-in pipeline.

Deriving intent this way is a workaround for the magic word overloading
LgRegexpWordCharacters, and can go once that is retired.
@HugoFara

Copy link
Copy Markdown
Owner Author

The safety claim in the description — "every language that exists today parses byte-identically; none of them stores a parser type" — doesn't hold on an upgraded install, and I caught it before merging by comparing parse output on a real 3.4.2 database.

db/migrations/20251223_120000_add_parser_type.sql backfills the column from the very legacy signals the opt-in test excludes:

UPDATE languages SET LgParserType = 'mecab'
 WHERE UPPER(TRIM(LgRegexpWordCharacters)) = 'MECAB' AND LgParserType IS NULL;
UPDATE languages SET LgParserType = 'character'
 WHERE LgSplitEachChar = 1 AND LgParserType IS NULL;

So every character-split and MeCab language already carries an explicit value — exactly the condition that routes to the registry.

Measured, TextParsing::checkText(), same texts and settings

Language LgParserType before with #281 as written
French (1) (none) 239 sent / 1058 words identical
German (3) (none) 35 / 527 identical
Chinese2 (4), Korean, Thai, Hebrew (none) identical
Chinese (2) character 12 / 103 13 / 122
Japanese (5) character 13 / 46 14 / 60

The claim holds perfectly where no type is stored, and fails on precisely the languages the backfill touched.

Nothing breaks at upgrade — existing texts keep their stored parse, since reparseAllTexts() only runs on the legacy textitems cache path. It diverges later and quietly: UpdateLanguage already re-parses when LgParserType changes, and any new or edited text tokenizes the new way. The language then holds texts split two different ways, and since terms link by string, saved vocabulary stops matching new occurrences. That is a miserable bug to trace from a user report.

Fix pushed (17262f5)

A stored type that only restates the flag beside it is read as the flag, not as intent — character with LgSplitEachChar, mecab with the magic word. Both carry no information the built-in pipeline isn't already acting on. Anything else could only have been chosen in the form: jieba, an external tokenizer, or character on a language whose split flag is off, since the backfill never wrote that pair.

Re-measured after the change:

lg=1 French    239 / 1058      lg=2 Chinese  12 / 103      lg=5 Japanese  13 / 46

Every language back to the pre-#281 baseline, exactly. And the feature still works — on this install the registry lists regex, character, mecab, jieba, mecab-python all available:

lg=4 LgParserType result
(none) 12 sent / 78 words
jieba 12 / 79 — the registry path runs
sudachi-not-installed 12 / 78 — falls back to the built-in pipeline, not to regex

Psalm 0 errors · PHPCS clean under the project ruleset · ParserRegistryTest 20 pass, 4 new.

Follow-up, not for this PR

This is a workaround for the MECAB magic word overloading LgRegexpWordCharacters, which is what makes a stored value ambiguous in the first place. Filed separately — the magic word is still written by the language form today, and in SentenceService it means "no spaces between words" rather than "use MeCab", so retiring it is its own change with its own before/after comparison.

@HugoFara
HugoFara merged commit 10bb342 into develop Aug 27, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Develop an existing feature new-feature A new feature ui Any problem related to the User Interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplified Chinese doesn't split words or allow splitting words

1 participant